可是我的心,比整個宇宙,還要大了那麼一點點。
-- 費爾南多‧佩索亞, 詩選:A Little Larger Than the Entire Universe.
過了那座牆,腳下有條筆直的步道,向著偌大區域的中央延展過去。走著走著,視野的旁邊總是會瞥到些閃爍著的什麼,但回過頭去卻又只剩下消失的微微的餘光。
順著路慢慢向前,迎來的是個廣場般的地方,正中間像是噴泉,廣場四周灌木叢若隱若現、外側是一樣黯淡的樹稀疏圍繞,而幽幽的影子如藤蔓般攀附於樹上、悄悄伸展。
當我走到噴泉的前面,原本向下涓流的水改變了角度,在我面前張開一整片的水簾。不管是外觀還是氣氛,都神似之前那些建築裡,最後提出考驗的平台。
流淌的水呼吸般閃爍著微微的光,像是等待著我回應它的提問。
但整個水幕,是一片純然的空白。
所以,那個問題會是什麼?我向遠處望去,透過點滴的水花,看見廣袤的天空,與緩緩飄動的雲。
知道了 Monad 其實只是個能坍縮成一層的容器這件事,似乎比不懂的時候還空虛。當然,這只是個比較好的起點而己。
你有沒有發現,我們一路在 Haskell 裡所示範的函式都很短,沒有超過一行的。回想一下之前在其它的程式語言,例如在 Elixir 裡,我們會寫這樣的函式:
# Elixir 語法
def foo(x) do
a = [x, x + 1, x + 2]
b = a |> Enum.map(fn i -> i * 2 end) # 注意這邊用到了上面的 a
a ++ b ++ [10] # 再用到了上面的 a 跟 b
end
foo(1)
但我們到目前為止都沒有看過在 Haskell 裡有類似的寫法:
-- 想像的 Haskell 語法
foo x =
a = [x, x + 1, x + 2]
b = fmap (*2) a -- 想用上面計算出來的 a
return a ++ b ++ [10] -- 再用到上面的 a 跟 b
因為辦不到。
在 Haskell 裡,函式的內容必須是一行連續的計算。不是兩行、也不是三行。可以使用之前提過的 pattern matching 拆成多個實作,也可以用一些手段把中間過程暫存起來,但函式的本體,就跟數學的函式一樣,只能是一行連續的計算。
IO
而剛剛那個還不是全部,更難搞 (或說純粹,端看你的立場) 的一點,是 Hakell 的函式要將純粹的計算與有副作用的部份區分開來。所謂的副作用是什麼呢?例如說取得使用者輸入、輸出到螢幕上、亂數、與資料庫溝通等等,這種不是純粹的計算,在 Haskell 中,都必須在特別的界限裡處理。而這條界限,就叫 IO。
當我們想要在其它語言,例如 Elixir 中,讀取使用者的輸入,並印到畫面上時,我們可以這樣寫:
# Elixir 語法
IO.gets(:stdin) |> IO.puts
但在 Haskell 中,是沒辦法這樣做的。這件事從函式的型別上就看得出來,讓我們一步一步來看。
-- 想像中的 Haskell 語法
putStrLn . getLine -- 會出錯
putStrLn
是用來把結果輸出到畫面上的函式,而取得使用者輸入,則是用 getLine
這個函式。讓我們來看一下它們的型別:
-- Haskell 語法
putStrLn :: String -> IO ()
getLine :: IO String
putStrLn
是個接受一個字串,回傳一個 IO
型別的函式。但 getLine
是回傳一個包在 IO
容器裡的 String
的函式。所以如果像上一小節那樣直接用 .
來函式組合,會因為輸出與輸入的型別不符而編譯失敗。
既然 getLine
會拿到包在 IO
容器裡的字串,而 putStrLn
需要的是一個字串,那我們可以用 fmap
嗎?
-- Haskell 語法
putStrLn <$> getLine :: IO (IO ())
這麼一來是可以編譯成功,但是沒有辦法順利印出來。因為 putStrLn
拿到字串後,會回傳 IO
,因此我們拿到的是包在 IO
容器裡的 IO
容器。
這就是我們需要 monad 坍縮性質的時候了。
-- Haskell 語法
(>>=) :: Monad m => m a -> (a -> m b) -> m b
(>>=) @ IO :: IO a -> (a -> IO b) -> IO b
因此寫成這樣才能做到我們想做的事:
-- Haskell 語法
getLine >>= putStrLn
你也許還記得,之前提到 Applicative 時,我們的第一步就是將一個接受多個參數的函式,升格成容器裡的函式。如果我們換一個角度來想,一旦這個計算過程被升格到容器裡後,要怎麼計算接下來接收到的參數這件事就再也沒有變動的空間了。如果我們再一次仔細看 liftA?系列的函式的型別,在接收到第一個函式之後,回傳的就是 f a -> f b -> f c
(liftA2),接下來需要幾個參數,每個的型別是什麼,都已經確定下來了。
但是在程式的運作過程中,我們常常會需要依照前面幾步的計算結果不同,來決定接下來要採取哪些計算。而這個就是 Monad 那個坍縮成一層的概念得以發揮的地方。
是的。正如我們之前所示範的,串列是一種 Monad,其 >>=
就是 concat $ map
。不過就跟 functor 的情況一樣,如果你只是想討論串列的話,那可以單純的用 concat $ map
(或其它語言的 flat_map
) 就足夠了*。
註*:順帶一提,以 functional reactive 著名的 Rx 框架也是用 flatMap
的概念來談坍縮兩層的 observable 結構為一的。
來舉個能展示 Monad 的用處的例子吧。假如我們想要知道丟兩個骰子所有可能的結果,而且我們把 (1, 2)
與 (2, 1)
視為同一種時,我們可以這樣寫:
-- Haskell 語法
[1..6] >>= \x ->
[x..6] >>= \y -> -- 注意這行,我們用了 x..6 排除掉前大後小的結果
return (x, y) -- 這裡用 return 函式,將結果裝回串列裡,才會符合型別要求
-- => [(1,1),(1,2),(1,3),(1,4),(1,5),(1,6),(2,2),(2,3),(2,4),(2,5),(2,6),(3,3),(3,4),(3,5),(3,6),(4,4),(4,5),(4,6),(5,5),(5,6),(6,6)]
注意在第一次 bind
中傳入的函式 \x -> [x..6]
的部份,我們依賴了之前計算的結果,來進行接下來的計算。而在最後做成元組時,我們用 return
這個函式,將元組再包一層串列的容器外殼。
而這個是串列的 Monad 實作:
-- Haskell 語法
instance Monad [] where
return = []
xs >>= f = concat $ map f xs
do
語法糖在上面的 Monad 示範中,我們用了兩個 >>=
函式來使用 Monad。而在 Haskell 中,有一種稱為 do 語法糖的東西,是讓這種連續的 Monad bind 更容易撰寫,看起來也有一點點像其它程式語言裡的指令式語法 (imperative),但請記得,它其實就是個 Monad:
-- Haskell 語法
rolls :: [(Integer, Integer)]
-- 把這個
rolls =
[1..6] >>= \x ->
[x..6] >>= \y ->
return (x, y)
-- 改寫成這樣
rolls = do
x <- [1..6]
y <- [x..6]
return (x, y)
這樣子就能看得出來,在 monad 中,把多套上一層容器的殼的函式叫做 return
,更加巧妙的讓這種語法寫起來的手感像是其它程式語言的慣例了。
是的。如果是兩層的 Just
的話,那就只留一層,除此之外都是 Nothing
。
-- Haskell 語法
instance Monad Maybe where
return x = Just x
Nothing >>= f = Nothing
Just x >>= f = f x
-- 示範
Just 3 >>= (\x -> Just "!" >>= (\y -> Just (show x ++ y))) -- => Just "3!"
-- do syntax
result = do
x <- Just 3
y <- Just "!"
return (show x ++ y)
是的。就跟 Applicative 一樣,二元組要成為 Monad 的條件,前面的元素也要是個 Monoid。這麼一來,當兩層的殼坍縮的時候,會用 mappend
將前面的元素併在一起:
-- Haskell 語法
instance Monoid a => Monad ((,) a) where
return b = (mempty, b)
(a, c) >>= f = let (b, c') = f c in (a <> b, c')
-- 示範
("hello", 2) >>= \x -> ("world", (x * 10)) -- => ("helloworld", 20)
Monad 在 Haskell 這麼受到重視,因為有許多問題的解答,就是利用 Monad 這個坍縮的性質來解決的。而這個性質,有非常多的用法,我們在這裡也僅能列舉一二。除了 IO 之外,還有 State,Reader,以及 Monad Transformer 等等。這些就需要更紮實的去理解 Haskell typeclass 的語法與實作,才有辦法好好的向下探索的。
而之所以其它語言不太談 Monad 這個詞,是因為沒有必要,在其它的語言裡,底下的程式碼是可以依賴上面的程式碼的,副作用也不是嚴格隔離的。在許多語言裡,變數就是可變的,程式的運作是大量依賴於那些變動的狀態在運行的。但是計算與副作用愈混雜,在某些情況下非常方便,但是也會付出另外一些代價,例如稍有不慎,就會寫出狀態與計算糾結在一起的程式碼,導致難以平行化,難以測試與除錯,難以拆解組合修改擴充,為此只好規範出許多的原則、設計模式等等。除此之外,有很多抽象之後,本質上相同的計算,非得要依不同的情況(型別)一次次瑣碎的重新實作......
但這不意味著這些函數式的觀念在其它語言派不上用場。當你可以看到事物的抽象本質時,就能夠在其它的語言裡把純粹計算的部份,與各種不同的副作用的部份各自分離,讓他們用最小耦合的方式待在一起互相合作。而函數式的部份就可以用上各種組抽象與組合的技巧。當然,怎麼跟這個程式語言的天性互補配合,而不是與它博鬥,又是另一個需要拿捏的地方了…
從我開始理解,陣列、 Maybe、IO、甚至函式本身也是一種 Monad 的那一刻起,有一種模糊的感覺出現在我的腦海裡,而我用盡力氣想要理解那究竟是什麼…於是我一次又一次的來回那個地方,探尋,閱讀,試驗,思索,而許多許多時日就那樣一個個流過。
直到那天,我坐在窗邊接著雨開始下了起來。不知道為什麼,我覺得這場雨,是熟悉的,曾經遇過的某一場雨。雖然景物,城市,一切的一切都已經不一樣了,但卻像是有個什麼,能夠跨越漫長漫長的時間,把過往的那個雨天,帶回到我面前……
我忽然意識到,這整座城市,由於其數學上連續計算、不可變動的、隔離副作用的本質,那麼為了要保持一個狀態,其本身就是一個不斷嵌疊的 Monad。
而我,站在這裡的我,只是那個描述整個世界的狀態,型別為 RealWorld
,萬千事物層疊的參數裡,非常非常小的一部份而己。
這個驅動世界運作的 Monad,一層接著一層,把整個世界的狀態傳遞給裡面那層的函式,接著進行計算、改變數值、解開外殼、然後再把結果傳遞給下一層的函式…如此不斷反覆,永不停歇,向著時間的盡頭運行…
………我曾經做過的夢,在這座城市裡,是它的現實。
我走回中央的廣場,用手指在水幕上面畫出程式碼……
要開始構造一個這樣的世界,要從把多個函式 >>=
在一起開始,然後讓一切運行起來的那個參數是……
隨著字一個個畫下,雨漸漸、漸漸的停了。天光如簾幕般柔和的、緩慢的降臨到這座城市裡,而萬物的色彩開始回歸。身旁原本透明的建築上開始渲染出顏色跟質感跟光與水的折射,一座座質樸學院樣貌的建築錯落開來。而在城市中央的區域裡,壯麗的塔樓與教堂直入雲霧…
「嘿。」
「蠻厲害的嘛。」
專注中、我聽到一個很久很久沒有聽到,但卻非常熟悉的聲音。
「red... red panda?」
我轉過頭去。
花園旁的矮柱上,我瞥見了棕紅色的毛,短短的胖爪子,膨膨的尾巴,那是過了許久許久,在記憶裡已快要褪去的身影。
還有那個帶著白線的眼角,跟總像是在思索著什麼的表情…
「你這次叫對了呢。」
~[FIN]~